Design Splitwise

Ashish

Ashish Pratap Singh

medium

In this chapter, we will explore the low-level design of a splitwise like service in detail.

Lets start by clarifying the requirements:

1. Clarifying Requirements

Before starting the design, it's important to ask thoughtful questions to uncover hidden assumptions and better define the scope of the system.

Here is an example of how a discussion between the candidate and the interviewer might unfold:

1.1 Functional Requirements

  • Support creation of both one-to-one and group expenses
  • Allow expenses to be split using different methods, such as equal split and exact amount
  • Track how much each user owes and is owed, across individuals and groups
  • Maintain a history of all expenses, payments, and settlements
  • Support partial settlements and update balances accordingly
  • Allow users to view outstanding balances per user and per group
  • Implement a debt simplification feature to minimize the number of transactions required to settle up

1.2 Non-Functional Requirements

  • Modularity: The design should be modular and follow object-oriented principles for easy maintenance and extension
  • Consistency: Expense updates and settlements should reflect accurately and immediately in the balance calculations
  • Extensibility: The system should be flexible enough to support additional split types (e.g., percentage-based) and multiple currencies
  • Thread-Safety: Multiple users might add expenses concurrently. Balance updates should be thread-safe to avoid race conditions.

After the requirements are clear, lets identify the core entities/objects we will have in our system.

2. Identifying Core Entities

Core entities are the fundamental building blocks of our system. We identify them by analyzing the functional requirements and highlighting the key nouns and responsibilities that naturally map to object-oriented abstractions such as classes, enums, or interfaces.

Let’s walk through the functional requirements and extract the relevant entities:

1. Users can create both one-to-one and group expenses.

This indicates the need for a User entity to represent each participant and a Group entity to model collections of users involved in shared expenses. Each group will have members and a list of associated expenses.

2. Expenses can be split in multiple ways, such as equal, exact, or percentage.

To support this, we need a Split entity that represents an individual participant’s share in a given expense.

We also need an abstract SplitStrategy interface (or base class), with concrete implementations like EqualSplit, ExactSplit, and PercentageSplit. These strategies determine how the total expense amount is divided among participants.

3. The system should track how much each user owes or is owed.

This requires a BalanceSheet component (or mapping) that maintains user-to-user debts, updated every time an expense or settlement is recorded.

4. Users should be able to make partial payments and settle balances over time.

To support this, we need a Transaction or Payment entity to represent payments between users, including partial settlements. Each payment should reference the payer, payee, amount, and timestamp.

5. All expenses and payments must be recorded with a complete history.

An Expense entity will represent each bill or transaction added to the system. It will include details such as description, amount, payer, date, involved users, and applied split strategy.

6. The system should support debt simplification to minimize the number of settlements needed.

This suggests the need for a DebtSimplifier utility class or component that runs on the group’s balance sheet and optimizes who pays whom and how much.

These core entities define the fundamental abstractions of a Splitwise-like system and will guide the class design, interactions, and responsibilities in the low-level implementation.

3. Designing Classes and Relationships

This section details the classes that form the core of the Splitwise system, their responsibilities, and the relationships between them.

3.1 Class Definitions

The system is designed around a set of core classes that model the real-world entities and concepts of expense sharing.

Data Classes

These classes are simple data containers with minimal logic, primarily used to store and transfer state.

User

Represents a participant in the system.

User

It holds user details like id, name, and email. Each User is intrinsically linked to their personal BalanceSheet.

Group

Represents a collection of Users who share expenses, such as for a trip or a household.

Group

It contains a name and a list of members.

Split

A simple data object representing a single portion of an Expense.

Split

It connects a User with the specific amount they owe for that expense.

Transaction

Represents a simplified, direct payment required to settle debts between two users, typically generated by the debt simplification logic.

Transaction

It holds the from user, the to user, and the amount.

Core Classes

These classes contain the main business logic and orchestrate the application's functionality.

Expense

Models a single spending event.

Expense

It's a complex object containing a description, total amount, who paidBy, and a list of Splits that define how the cost is distributed. It uses a SplitStrategy to calculate these splits.

ExpenseBuilder

A nested builder class within Expense. It provides a fluent API to construct a complex Expense object step-by-step, accommodating different splitting methods and their required parameters.

BalanceSheet

A crucial component that tracks a User's financial state relative to other users.

BalanceSheet

It maintains a map where each entry represents a net balance with another user. A positive value means the other user owes the owner, and a negative value means the owner owes the other user.

SplitwiseService

Acts as the central controller and entry point for the application.

SplitwiseService

It manages users and groups, orchestrates expense creation, and provides high-level functionalities like showing balances and simplifying debts.

3.2 Class Relationships

The classes interact through a combination of composition and association, creating a cohesive system.

Composition

A strong "has-a" relationship where the child object's lifecycle is managed by the parent.

  • User and BalanceSheet: Each User has one BalanceSheet. The BalanceSheet is created when the User is instantiated and is fundamentally part of the User's state. User 1 -- 1 BalanceSheet.
  • Expense and Split: An Expense has one or more Splits. The Split objects are created during the Expense's construction and have no meaning outside the context of that specific expense. Expense 1 -- 1..* Split.
  • SplitwiseService and other objects: The SplitwiseService manages the lifecycle of all User and Group objects, acting as a container for them.

Association

A weaker "has-a" relationship where objects are related but can exist independently.

  • Group and User: A Group has multiple Users as members. Users can exist without being in a group and can be part of multiple groups. Group 1 -- * User.
  • Expense and User: An Expense is associated with one User who paid (paidBy) and multiple Users who participated. Expense * -- 1 User.
  • Expense and SplitStrategy: An Expense uses one SplitStrategy to perform its split calculation. The strategy is passed in during construction. Expense * -- 1 SplitStrategy.
  • Split and User: A Split is associated with the User who owes that portion of the expense. Split * -- 1 User.
  • Transaction and User: A Transaction connects two Users (a payer and a payee). Transaction * -- 2 User.

Implementation

An "is-a" relationship.

  • EqualSplitStrategy, ExactSplitStrategy, and PercentageSplitStrategy are all implementations of the SplitStrategy interface.

3.3 Key Design Patterns

The design leverages several well-known patterns to ensure flexibility, maintainability, and ease of use.

Strategy Pattern

SplitStrategy

This pattern is used to handle the different ways an expense can be split. The SplitStrategy interface defines a common contract for all splitting algorithms. Concrete classes like EqualSplitStrategy, ExactSplitStrategy, and PercentageSplitStrategy provide specific implementations. The Expense class is configured with one of these strategies at runtime, allowing the splitting logic to be selected and changed dynamically without altering the Expense class itself.

Builder Pattern

The Expense object has a complex construction process, requiring different sets of parameters depending on the chosen split strategy. The Expense.ExpenseBuilder class simplifies this by providing a fluent, step-by-step interface for creating an Expense instance. This improves code readability and makes the Expense object's state immutable after construction.

Facade Pattern

The SplitwiseService also acts as a Facade. It provides a simple, unified interface (createExpense, settleUp, simplifyGroupDebts) to the client. This hides the underlying complexity of interactions between User, BalanceSheet, Expense, and Split objects. The client code only needs to interact with the Facade, making the system easier to use and decoupling the client from the internal implementation.

Singleton Pattern

The SplitwiseService is implemented as a Singleton. This ensures that there is only one instance of the service throughout the application, providing a single, global point of access and control for managing all users, groups, and expenses. This prevents state inconsistencies that could arise from multiple service instances.

3.4 Full Class Diagram

Splitwise Class Diagram

4. Implementation

4.1 User

Represents a system user.

1class User:
2    def __init__(self, name: str, email: str):
3        self._id = str(uuid.uuid4())
4        self._name = name
5        self._email = email
6        self._balance_sheet = BalanceSheet(self)
7    
8    def get_id(self) -> str:
9        return self._id
10    
11    def get_name(self) -> str:
12        return self._name
13    
14    def get_balance_sheet(self) -> 'BalanceSheet':
15        return self._balance_sheet

Each user has an associated BalanceSheet that tracks net debts or credits with other users.

4.2 Transaction

Represents a simplified debt between two users, typically the result of group debt consolidation.

1class Transaction:
2    def __init__(self, from_user: User, to_user: User, amount: float):
3        self._from = from_user
4        self._to = to_user
5        self._amount = amount
6    
7    def __str__(self) -> str:
8        return f"{self._from.get_name()} should pay {self._to.get_name()} ${self._amount:.2f}"

4.3 Split

Represents how much a particular user owes in a specific expense.

1class Split:
2    def __init__(self, user: User, amount: float):
3        self._user = user
4        self._amount = amount
5    
6    def get_user(self) -> User:
7        return self._user
8    
9    def get_amount(self) -> float:
10        return self._amount

4.4 Group

Defines a group context (e.g., "Friends Trip") in which expenses can be shared and simplified.

1class Group:
2    def __init__(self, name: str, members: List[User]):
3        self._id = str(uuid.uuid4())
4        self._name = name
5        self._members = members
6    
7    def get_id(self) -> str:
8        return self._id
9    
10    def get_name(self) -> str:
11        return self._name
12    
13    def get_members(self) -> List[User]:
14        return self._members.copy()

4.5 Expense and Builder

Represents an expense paid by one user and shared among others.

Creating an Expense can be complex, as it requires different parameters depending on the split type. The Builder pattern provides a flexible and readable way to construct Expense objects.

1class Expense:
2    def __init__(self, builder: 'ExpenseBuilder'):
3        self._id = builder._id
4        self._description = builder._description
5        self._amount = builder._amount
6        self._paid_by = builder._paid_by
7        self._timestamp = datetime.now()
8        
9        # Use the strategy to calculate splits
10        self._splits = builder._split_strategy.calculate_splits(
11            builder._amount, builder._paid_by, builder._participants, builder._split_values
12        )
13    
14    def get_id(self) -> str:
15        return self._id
16    
17    def get_description(self) -> str:
18        return self._description
19    
20    def get_amount(self) -> float:
21        return self._amount
22    
23    def get_paid_by(self) -> User:
24        return self._paid_by
25    
26    def get_splits(self) -> List[Split]:
27        return self._splits
28    
29    class ExpenseBuilder:
30        def __init__(self):
31            self._id: Optional[str] = None
32            self._description: Optional[str] = None
33            self._amount: Optional[float] = None
34            self._paid_by: Optional[User] = None
35            self._participants: Optional[List[User]] = None
36            self._split_strategy: Optional[SplitStrategy] = None
37            self._split_values: Optional[List[float]] = None
38        
39        def set_id(self, expense_id: str) -> 'Expense.ExpenseBuilder':
40            self._id = expense_id
41            return self
42        
43        def set_description(self, description: str) -> 'Expense.ExpenseBuilder':
44            self._description = description
45            return self
46        
47        def set_amount(self, amount: float) -> 'Expense.ExpenseBuilder':
48            self._amount = amount
49            return self
50        
51        def set_paid_by(self, paid_by: User) -> 'Expense.ExpenseBuilder':
52            self._paid_by = paid_by
53            return self
54        
55        def set_participants(self, participants: List[User]) -> 'Expense.ExpenseBuilder':
56            self._participants = participants
57            return self
58        
59        def set_split_strategy(self, split_strategy: SplitStrategy) -> 'Expense.ExpenseBuilder':
60            self._split_strategy = split_strategy
61            return self
62        
63        def set_split_values(self, split_values: List[float]) -> 'Expense.ExpenseBuilder':
64            self._split_values = split_values
65            return self
66        
67        def build(self) -> 'Expense':
68            if self._split_strategy is None:
69                raise ValueError("Split strategy is required.")
70            return Expense(self)

Splits are calculated using a pluggable SplitStrategy.

4.6 BalanceSheet

Tracks a user's financial relationship with others.

1class BalanceSheet:
2    def __init__(self, owner: User):
3        self._owner = owner
4        self._balances: Dict[User, float] = {}
5        self._lock = threading.Lock()
6    
7    def get_balances(self) -> Dict[User, float]:
8        return self._balances
9    
10    def adjust_balance(self, other_user: User, amount: float):
11        with self._lock:
12            if self._owner == other_user:
13                return  # Cannot owe yourself
14            
15            if other_user in self._balances:
16                self._balances[other_user] += amount
17            else:
18                self._balances[other_user] = amount
19    
20    def show_balances(self):
21        print(f"--- Balance Sheet for {self._owner.get_name()} ---")
22        if not self._balances:
23            print("All settled up!")
24            return
25        
26        total_owed_to_me = 0
27        total_i_owe = 0
28        
29        for other_user, amount in self._balances.items():
30            if amount > 0.01:
31                print(f"{other_user.get_name()} owes {self._owner.get_name()} ${amount:.2f}")
32                total_owed_to_me += amount
33            elif amount < -0.01:
34                print(f"{self._owner.get_name()} owes {other_user.get_name()} ${-amount:.2f}")
35                total_i_owe += (-amount)
36        
37        print(f"Total Owed to {self._owner.get_name()}: ${total_owed_to_me:.2f}")
38        print(f"Total {self._owner.get_name()} Owes: ${total_i_owe:.2f}")
39        print("---------------------------------")
  • Positive value → others owe the owner
  • Negative value → owner owes othersThis abstraction allows O(1) retrieval and update for each user-user balance.

4.7 SplitStrategy and Implementations

To handle different ways of splitting an expense (equally, by exact amounts, by percentage), we use the Strategy pattern.

1class SplitStrategy(ABC):
2    @abstractmethod
3    def calculate_splits(self, total_amount: float, paid_by: User, participants: List[User], split_values: Optional[List[float]]) -> List[Split]:
4        pass
5
6class EqualSplitStrategy(SplitStrategy):
7    def calculate_splits(self, total_amount: float, paid_by: User, participants: List[User], split_values: Optional[List[float]]) -> List[Split]:
8        splits = []
9        amount_per_person = total_amount / len(participants)
10        for participant in participants:
11            splits.append(Split(participant, amount_per_person))
12        return splits
13
14class ExactSplitStrategy(SplitStrategy):
15    def calculate_splits(self, total_amount: float, paid_by: User, participants: List[User], split_values: Optional[List[float]]) -> List[Split]:
16        if len(participants) != len(split_values):
17            raise ValueError("Number of participants and split values must match.")
18        if abs(sum(split_values) - total_amount) > 0.01:
19            raise ValueError("Sum of exact amounts must equal the total expense amount.")
20        
21        splits = []
22        for i in range(len(participants)):
23            splits.append(Split(participants[i], split_values[i]))
24        return splits
25
26class PercentageSplitStrategy(SplitStrategy):
27    def calculate_splits(self, total_amount: float, paid_by: User, participants: List[User], split_values: Optional[List[float]]) -> List[Split]:
28        if len(participants) != len(split_values):
29            raise ValueError("Number of participants and split values must match.")
30        if abs(sum(split_values) - 100.0) > 0.01:
31            raise ValueError("Sum of percentages must be 100.")
32        
33        splits = []
34        for i in range(len(participants)):
35            amount = (total_amount * split_values[i]) / 100.0
36            splits.append(Split(participants[i], amount))
37        return splits
  • Pluggable Algorithms: The SplitStrategy interface defines a contract for any splitting algorithm. The Expense object is configured with a concrete strategy to perform the split calculation.
  • Flexibility and Extensibility: This design is highly extensible. To add a new way of splitting (e.g., by shares), we would simply create a new ShareSplitStrategy class implementing the interface, with no changes needed to the Expense or SplitwiseService classes.
  • Validation: Each concrete strategy is responsible for validating its specific inputs (e.g., ExactSplitStrategy ensures the split amounts sum up to the total expense).

4.8 SplitwiseService (Singleton)

This class acts as a Singleton and a Facade, providing a single, simplified entry point for all application functionalities.

1class SplitwiseService:
2    _instance = None
3    _lock = threading.Lock()
4    
5    def __new__(cls):
6        if cls._instance is None:
7            with cls._lock:
8                if cls._instance is None:
9                    cls._instance = super().__new__(cls)
10                    cls._instance._initialized = False
11        return cls._instance
12    
13    def __init__(self):
14        if not self._initialized:
15            self._users: Dict[str, User] = {}
16            self._groups: Dict[str, Group] = {}
17            self._initialized = True
18    
19    @classmethod
20    def get_instance(cls):
21        return cls()
22    
23    def add_user(self, name: str, email: str) -> User:
24        user = User(name, email)
25        self._users[user.get_id()] = user
26        return user
27    
28    def add_group(self, name: str, members: List[User]) -> Group:
29        group = Group(name, members)
30        self._groups[group.get_id()] = group
31        return group
32    
33    def get_user(self, user_id: str) -> Optional[User]:
34        return self._users.get(user_id)
35    
36    def get_group(self, group_id: str) -> Optional[Group]:
37        return self._groups.get(group_id)
38    
39    def create_expense(self, builder: Expense.ExpenseBuilder):
40        with self._lock:
41            expense = builder.build()
42            paid_by = expense.get_paid_by()
43            
44            for split in expense.get_splits():
45                participant = split.get_user()
46                amount = split.get_amount()
47                
48                if paid_by != participant:
49                    paid_by.get_balance_sheet().adjust_balance(participant, amount)
50                    participant.get_balance_sheet().adjust_balance(paid_by, -amount)
51            
52            print(f"Expense '{expense.get_description()}' of amount {expense.get_amount()} created.")
53    
54    def settle_up(self, payer_id: str, payee_id: str, amount: float):
55        with self._lock:
56            payer = self._users[payer_id]
57            payee = self._users[payee_id]
58            print(f"{payer.get_name()} is settling up {amount} with {payee.get_name()}")
59            
60            # Settlement is like a reverse expense. payer owes less to payee.
61            payee.get_balance_sheet().adjust_balance(payer, -amount)
62            payer.get_balance_sheet().adjust_balance(payee, amount)
63    
64    def show_balance_sheet(self, user_id: str):
65        user = self._users[user_id]
66        user.get_balance_sheet().show_balances()
67    
68    def simplify_group_debts(self, group_id: str) -> List[Transaction]:
69        group = self._groups.get(group_id)
70        if group is None:
71            raise ValueError("Group not found")
72        
73        # Calculate net balance for each member within the group context
74        net_balances = {}
75        for member in group.get_members():
76            balance = 0
77            for other_user, amount in member.get_balance_sheet().get_balances().items():
78                # Consider only balances with other group members
79                if other_user in group.get_members():
80                    balance += amount
81            net_balances[member] = balance
82        
83        # Separate into creditors and debtors
84        creditors = [(user, balance) for user, balance in net_balances.items() if balance > 0]
85        debtors = [(user, balance) for user, balance in net_balances.items() if balance < 0]
86        
87        creditors.sort(key=lambda x: x[1], reverse=True)
88        debtors.sort(key=lambda x: x[1])
89        
90        transactions = []
91        i = j = 0
92        
93        while i < len(creditors) and j < len(debtors):
94            creditor_user, creditor_amount = creditors[i]
95            debtor_user, debtor_amount = debtors[j]
96            
97            amount_to_settle = min(creditor_amount, -debtor_amount)
98            transactions.append(Transaction(debtor_user, creditor_user, amount_to_settle))
99            
100            creditors[i] = (creditor_user, creditor_amount - amount_to_settle)
101            debtors[j] = (debtor_user, debtor_amount + amount_to_settle)
102            
103            if abs(creditors[i][1]) < 0.01:
104                i += 1
105            if abs(debtors[j][1]) < 0.01:
106                j += 1
107        
108        return transactions
  • Facade Pattern: It hides the internal complexity from the client. The client doesn't need to know about BalanceSheet or Split objects; they just call createExpense with a builder.
  • Expense Logic (createExpense): This core method performs the crucial step of updating the balance sheets for both the payer and all participants based on the calculated splits. The updates are reciprocal: if Bob owes Alice $10, Alice's sheet is updated with +10 for Bob, and Bob's sheet is updated with -10 for Alice.
  • Debt Simplification: The simplifyGroupDebts method implements a classic greedy algorithm. It calculates the net balance of each user within a group, separates them into creditors (owed money) and debtors (owe money), and then generates the minimum number of transactions required to clear all debts.

4.9 SplitwiseDemo

This driver class simulates real-world usage of the service, demonstrating all the key features.

1class SplitwiseDemo:
2    @staticmethod
3    def main():
4        # 1. Setup the service
5        service = SplitwiseService.get_instance()
6        
7        # 2. Create users and groups
8        alice = service.add_user("Alice", "[email protected]")
9        bob = service.add_user("Bob", "[email protected]")
10        charlie = service.add_user("Charlie", "[email protected]")
11        david = service.add_user("David", "[email protected]")
12        
13        friends_group = service.add_group("Friends Trip", [alice, bob, charlie, david])
14        
15        print("--- System Setup Complete ---\n")
16        
17        # 3. Use Case 1: Equal Split
18        print("--- Use Case 1: Equal Split ---")
19        service.create_expense(Expense.ExpenseBuilder()
20                              .set_description("Dinner")
21                              .set_amount(1000)
22                              .set_paid_by(alice)
23                              .set_participants([alice, bob, charlie, david])
24                              .set_split_strategy(EqualSplitStrategy()))
25        
26        service.show_balance_sheet(alice.get_id())
27        service.show_balance_sheet(bob.get_id())
28        print()
29        
30        # 4. Use Case 2: Exact Split
31        print("--- Use Case 2: Exact Split ---")
32        service.create_expense(Expense.ExpenseBuilder()
33                              .set_description("Movie Tickets")
34                              .set_amount(370)
35                              .set_paid_by(alice)
36                              .set_participants([bob, charlie])
37                              .set_split_strategy(ExactSplitStrategy())
38                              .set_split_values([120.0, 250.0]))
39        
40        service.show_balance_sheet(alice.get_id())
41        service.show_balance_sheet(bob.get_id())
42        print()
43        
44        # 5. Use Case 3: Percentage Split
45        print("--- Use Case 3: Percentage Split ---")
46        service.create_expense(Expense.ExpenseBuilder()
47                              .set_description("Groceries")
48                              .set_amount(500)
49                              .set_paid_by(david)
50                              .set_participants([alice, bob, charlie])
51                              .set_split_strategy(PercentageSplitStrategy())
52                              .set_split_values([40.0, 30.0, 30.0]))  # 40%, 30%, 30%
53        
54        print("--- Balances After All Expenses ---")
55        service.show_balance_sheet(alice.get_id())
56        service.show_balance_sheet(bob.get_id())
57        service.show_balance_sheet(charlie.get_id())
58        service.show_balance_sheet(david.get_id())
59        print()
60        
61        # 6. Use Case 4: Simplify Group Debts
62        print("--- Use Case 4: Simplify Group Debts for 'Friends Trip' ---")
63        simplified_debts = service.simplify_group_debts(friends_group.get_id())
64        if not simplified_debts:
65            print("All debts are settled within the group!")
66        else:
67            for debt in simplified_debts:
68                print(debt)
69        print()
70        
71        service.show_balance_sheet(bob.get_id())
72        
73        # 7. Use Case 5: Partial Settlement
74        print("--- Use Case 5: Partial Settlement ---")
75        # From the simplified debts, we see Bob should pay Alice. Let's say Bob pays 100.
76        service.settle_up(bob.get_id(), alice.get_id(), 100)
77        
78        print("--- Balances After Partial Settlement ---")
79        service.show_balance_sheet(alice.get_id())
80        service.show_balance_sheet(bob.get_id())
81
82if __name__ == "__main__":
83    SplitwiseDemo.main()

5. Run and Test

Languages
Java
C#
Python
C++
Files12
entities
strategies
splitwise_demo.py
main
splitwise_service.py
splitwise_demo.py
Output

6. Quiz

Design Splitwise - Quiz

1 / 20
Multiple Choice

Which entity in a Splitwise-like system is responsible for keeping track of what each user owes and is owed?

How helpful was this article?

Comments


0/2000

No comments yet. Be the first to comment!

Copilot extension content script